Micron Document
Fox's Git Mirrors

Commit f2ab3deee24aa77f9d15b854bf590c9f2b9ba125


Parents : 3f3d007
Author : Ivan <e318cbc04468bd574db2b4523dddd710>
Signature : T66BB85Valid, signed by author
Date : 2026-08-15T02:38:24-05:00

refactor: simplify AES encryption and decryption by implementing CBC mode directly, and add corresponding tests for CBC functionality

Changes
Diff

diff --git a/pkg/cryptography/aes.go b/pkg/cryptography/aes.go
index e88918b5..9eb7bf95 100644
--- a/pkg/cryptography/aes.go
+++ b/pkg/cryptography/aes.go
@@ -5,7 +5,6 @@ package cryptography
import (
"crypto/aes"
- "crypto/cipher"
"crypto/rand"
"errors"
"io"
@@ -29,22 +28,20 @@ func implEncryptAES256CBC(key, plaintext []byte) ([]byte, error) {
return nil, err
}
- iv := make([]byte, aes.BlockSize)
- if _, err := io.ReadFull(rand.Reader, iv); err != nil {
+ padding := aes.BlockSize - len(plaintext)%aes.BlockSize
+ ctLen := len(plaintext) + padding
+ out := make([]byte, aes.BlockSize+ctLen)
+ if _, err := io.ReadFull(rand.Reader, out[:aes.BlockSize]); err != nil {
return nil, err
}
-
- padding := aes.BlockSize - len(plaintext)%aes.BlockSize
- padtext := make([]byte, len(plaintext)+padding)
- copy(padtext, plaintext)
- for i := len(plaintext); i < len(padtext); i++ {
- padtext[i] = byte(padding)
+ copy(out[aes.BlockSize:aes.BlockSize+len(plaintext)], plaintext)
+ padByte := byte(padding)
+ for i := aes.BlockSize + len(plaintext); i < aes.BlockSize+ctLen; i++ {
+ out[i] = padByte
+ }
+ if err := EncryptCBC(block, out[:aes.BlockSize], out[aes.BlockSize:]); err != nil {
+ return nil, err
}
-
- mode := cipher.NewCBCEncrypter(block, iv) // #nosec G407
- out := make([]byte, aes.BlockSize+len(padtext))
- copy(out[:aes.BlockSize], iv)
- mode.CryptBlocks(out[aes.BlockSize:], padtext)
return out, nil
}
@@ -63,16 +60,14 @@ func implDecryptAES256CBC(key, ciphertext []byte) ([]byte, error) {
}
iv := ciphertext[:aes.BlockSize]
- ciphertext = ciphertext[aes.BlockSize:]
-
- if len(ciphertext)%aes.BlockSize != 0 {
+ ct := ciphertext[aes.BlockSize:]
+ if len(ct)%aes.BlockSize != 0 {
return nil, errors.New("ciphertext is not a multiple of the block size")
}
-
- mode := cipher.NewCBCDecrypter(block, iv)
- plaintext := make([]byte, len(ciphertext))
- mode.CryptBlocks(plaintext, ciphertext)
-
+ plaintext := make([]byte, len(ct))
+ if err := DecryptCBC(block, iv, ct, plaintext); err != nil {
+ return nil, err
+ }
return RemovePKCS7Padding(plaintext)
}

diff --git a/pkg/cryptography/cbc.go b/pkg/cryptography/cbc.go
new file mode 100644
index 00000000..6e1eabcc
--- /dev/null
+++ b/pkg/cryptography/cbc.go
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package cryptography
+
+import (
+ "crypto/cipher"
+ "crypto/subtle"
+ "errors"
+)
+
+var errCBCArgs = errors.New("invalid CBC arguments")
+
+// EncryptCBC encrypts buf in place with AES-CBC. iv is the initialization
+// vector and is not written to buf. buf must be a multiple of the block size.
+func EncryptCBC(block cipher.Block, iv, buf []byte) error {
+ if block == nil {
+ return errCBCArgs
+ }
+ bs := block.BlockSize()
+ if len(iv) != bs || len(buf)%bs != 0 {
+ return errCBCArgs
+ }
+ prev := iv
+ for i := 0; i < len(buf); i += bs {
+ chunk := buf[i : i+bs]
+ subtle.XORBytes(chunk, chunk, prev)
+ block.Encrypt(chunk, chunk)
+ prev = chunk
+ }
+ return nil
+}
+
+// DecryptCBC decrypts src into dst with AES-CBC. src and dst may be the same
+// slice. iv is the initialization vector. src and dst must be the same length
+// and a multiple of the block size.
+func DecryptCBC(block cipher.Block, iv, src, dst []byte) error {
+ if block == nil {
+ return errCBCArgs
+ }
+ bs := block.BlockSize()
+ if len(iv) != bs || len(src) != len(dst) || len(src)%bs != 0 {
+ return errCBCArgs
+ }
+ var prev, ciph [32]byte
+ if bs > len(prev) {
+ return errCBCArgs
+ }
+ copy(prev[:], iv)
+ for i := 0; i < len(src); i += bs {
+ copy(ciph[:], src[i:i+bs])
+ block.Decrypt(dst[i:i+bs], src[i:i+bs])
+ subtle.XORBytes(dst[i:i+bs], dst[i:i+bs], prev[:bs])
+ copy(prev[:], ciph[:bs])
+ }
+ return nil
+}

diff --git a/pkg/cryptography/cbc_test.go b/pkg/cryptography/cbc_test.go
new file mode 100644
index 00000000..e8a7d7d8
--- /dev/null
+++ b/pkg/cryptography/cbc_test.go
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package cryptography
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "testing"
+)
+
+func TestEncryptDecryptCBCMatchesStdlib(t *testing.T) {
+ key := make([]byte, AES256KeySize)
+ if _, err := rand.Read(key); err != nil {
+ t.Fatal(err)
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ plaintexts := [][]byte{
+ bytes.Repeat([]byte{0x11}, aes.BlockSize),
+ bytes.Repeat([]byte{0x22}, aes.BlockSize*2),
+ bytes.Repeat([]byte{0x33}, aes.BlockSize*5),
+ make([]byte, aes.BlockSize),
+ }
+ for _, pt := range plaintexts {
+ iv := make([]byte, aes.BlockSize)
+ if _, err := rand.Read(iv); err != nil {
+ t.Fatal(err)
+ }
+
+ want := make([]byte, len(pt))
+ copy(want, pt)
+ cipher.NewCBCEncrypter(block, iv).CryptBlocks(want, want)
+
+ got := append([]byte(nil), pt...)
+ if err := EncryptCBC(block, iv, got); err != nil {
+ t.Fatalf("EncryptCBC: %v", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("encrypt mismatch\ngot %x\nwant %x", got, want)
+ }
+
+ dec := make([]byte, len(got))
+ if err := DecryptCBC(block, iv, got, dec); err != nil {
+ t.Fatalf("DecryptCBC: %v", err)
+ }
+ if !bytes.Equal(dec, pt) {
+ t.Fatalf("decrypt mismatch\ngot %x\nwant %x", dec, pt)
+ }
+
+ inPlace := append([]byte(nil), got...)
+ if err := DecryptCBC(block, iv, inPlace, inPlace); err != nil {
+ t.Fatalf("DecryptCBC in place: %v", err)
+ }
+ if !bytes.Equal(inPlace, pt) {
+ t.Fatalf("in-place decrypt mismatch")
+ }
+ }
+}
+
+func TestEncryptCBCRejectsBadArgs(t *testing.T) {
+ key := make([]byte, AES256KeySize)
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := EncryptCBC(nil, make([]byte, 16), make([]byte, 16)); err == nil {
+ t.Fatal("expected nil block error")
+ }
+ if err := EncryptCBC(block, make([]byte, 15), make([]byte, 16)); err == nil {
+ t.Fatal("expected bad iv error")
+ }
+ if err := EncryptCBC(block, make([]byte, 16), make([]byte, 15)); err == nil {
+ t.Fatal("expected bad buf error")
+ }
+}

diff --git a/pkg/cryptography/token.go b/pkg/cryptography/token.go
index 059da15c..c6c90445 100644
--- a/pkg/cryptography/token.go
+++ b/pkg/cryptography/token.go
@@ -5,7 +5,6 @@ package cryptography
import (
"crypto/aes"
- "crypto/cipher"
"crypto/rand"
"errors"
"io"
@@ -73,20 +72,20 @@ func encryptAESCBC(key, plaintext []byte) ([]byte, error) {
if err != nil {
return nil, err
}
- iv := make([]byte, aes.BlockSize)
- if _, err := io.ReadFull(rand.Reader, iv); err != nil {
+ padding := aes.BlockSize - len(plaintext)%aes.BlockSize
+ ctLen := len(plaintext) + padding
+ out := make([]byte, aes.BlockSize+ctLen)
+ if _, err := io.ReadFull(rand.Reader, out[:aes.BlockSize]); err != nil {
+ return nil, err
+ }
+ copy(out[aes.BlockSize:aes.BlockSize+len(plaintext)], plaintext)
+ padByte := byte(padding)
+ for i := aes.BlockSize + len(plaintext); i < aes.BlockSize+ctLen; i++ {
+ out[i] = padByte
+ }
+ if err := EncryptCBC(block, out[:aes.BlockSize], out[aes.BlockSize:]); err != nil {
return nil, err
}
- padding := aes.BlockSize - len(plaintext)%aes.BlockSize
- padtext := make([]byte, len(plaintext)+padding)
- copy(padtext, plaintext)
- for i := len(plaintext); i < len(padtext); i++ {
- padtext[i] = byte(padding)
- }
- mode := cipher.NewCBCEncrypter(block, iv) // #nosec G407
- out := make([]byte, aes.BlockSize+len(padtext))
- copy(out[:aes.BlockSize], iv)
- mode.CryptBlocks(out[aes.BlockSize:], padtext)
return out, nil
}
@@ -109,8 +108,9 @@ func decryptAESCBC(key, ciphertext []byte) ([]byte, error) {
if len(ct)%aes.BlockSize != 0 {
return nil, errors.New("ciphertext is not a multiple of the block size")
}
- mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ct))
- mode.CryptBlocks(plaintext, ct)
+ if err := DecryptCBC(block, iv, ct, plaintext); err != nil {
+ return nil, err
+ }
return RemovePKCS7Padding(plaintext)
}

diff --git a/pkg/interfaces/interface.go b/pkg/interfaces/interface.go
index 527b9375..be82cf06 100644
--- a/pkg/interfaces/interface.go
+++ b/pkg/interfaces/interface.go
@@ -377,7 +377,9 @@ func (i *BaseInterface) Send(data []byte, address string) error {
if err := common.RejectReceiveOnly(i); err != nil {
return err
}
- debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", i.Name, "bytes", len(data), "address", address)
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", i.Name, "bytes", len(data), "address", address)
+ }
masked, err := common.ApplyIFACOutbound(i, data)
if err != nil {

diff --git a/pkg/interfaces/tcp.go b/pkg/interfaces/tcp.go
index f140d4c1..6a7c1658 100644
--- a/pkg/interfaces/tcp.go
+++ b/pkg/interfaces/tcp.go
@@ -253,7 +253,9 @@ func (tc *TCPClientInterface) Send(data []byte, address string) error {
if err := common.RejectReceiveOnly(tc); err != nil {
return err
}
- debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", tc.Name, "bytes", len(data), "address", address)
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", tc.Name, "bytes", len(data), "address", address)
+ }
masked, err := common.ApplyIFACOutbound(tc, data)
if err != nil {
@@ -806,7 +808,9 @@ func (ts *TCPServerInterface) Send(data []byte, address string) error {
if err := common.RejectReceiveOnly(ts); err != nil {
return err
}
- debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", ts.Name, "bytes", len(data), "address", address)
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", ts.Name, "bytes", len(data), "address", address)
+ }
masked, err := common.ApplyIFACOutbound(ts, data)
if err != nil {

diff --git a/pkg/interfaces/tcp_other.go b/pkg/interfaces/tcp_other.go
new file mode 100644
index 00000000..8e94a0b0
--- /dev/null
+++ b/pkg/interfaces/tcp_other.go
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+//go:build !linux && !darwin && !windows && !freebsd && !openbsd && !netbsd && !dragonfly && !haiku && !(js && wasm)
+
+package interfaces
+
+import (
+ "fmt"
+ "net"
+ "runtime"
+ "time"
+
+ "quad4/reticulum-go/pkg/debug"
+)
+
+func (tc *TCPClientInterface) setTimeoutsLinux() error {
+ tcpConn, ok := tc.conn.(*net.TCPConn)
+ if !ok {
+ return fmt.Errorf("not a TCP connection")
+ }
+
+ if err := tcpConn.SetKeepAlive(true); err != nil {
+ return fmt.Errorf("failed to enable keepalive: %w", err)
+ }
+
+ keepalivePeriod := TCPProbeIntervalSec * time.Second
+ if tc.i2pTunneled {
+ keepalivePeriod = I2PProbeIntervalSec * time.Second
+ }
+
+ if err := tcpConn.SetKeepAlivePeriod(keepalivePeriod); err != nil {
+ debug.Log(debug.DebugVerbose, "Failed to set keepalive period", "error", err)
+ }
+
+ debug.Log(debug.DebugVerbose, "TCP keepalive configured", "goos", runtime.GOOS, "i2p", tc.i2pTunneled)
+ return nil
+}
+
+func (tc *TCPClientInterface) setTimeoutsOSX() error {
+ return tc.setTimeoutsLinux()
+}

diff --git a/pkg/interfaces/udp.go b/pkg/interfaces/udp.go
index 922d07c8..989b9e2f 100644
--- a/pkg/interfaces/udp.go
+++ b/pkg/interfaces/udp.go
@@ -227,7 +227,9 @@ func (ui *UDPInterface) Send(data []byte, address string) error {
if err := common.RejectReceiveOnly(ui); err != nil {
return err
}
- debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", ui.Name, "bytes", len(data), "address", address)
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Interface sending bytes", "name", ui.Name, "bytes", len(data), "address", address)
+ }
masked, err := common.ApplyIFACOutbound(ui, data)
if err != nil {

diff --git a/pkg/link/encrypt_bench_test.go b/pkg/link/encrypt_bench_test.go
new file mode 100644
index 00000000..139193f9
--- /dev/null
+++ b/pkg/link/encrypt_bench_test.go
@@ -0,0 +1,198 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package link
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/sha256"
+ "testing"
+
+ "quad4/reticulum-go/pkg/packet"
+)
+
+func handshakeLink(t testing.TB) *Link {
+ t.Helper()
+ l := &Link{linkID: bytes.Repeat([]byte{0x0C}, 16)}
+ if err := l.generateEphemeralKeys(); err != nil {
+ t.Fatal(err)
+ }
+ l.peerPub = l.pub
+ l.mode = ModeAES256CBC
+ if err := l.performHandshake(); err != nil {
+ t.Fatal(err)
+ }
+ return l
+}
+
+func TestEncryptLockedIntoRoundTrip(t *testing.T) {
+ l := handshakeLink(t)
+ plain := []byte("encrypt-into-dst")
+ n := encryptedPayloadLen(len(plain))
+ dst := make([]byte, n)
+ l.mutex.Lock()
+ ct, err := l.encryptLockedInto(plain, dst)
+ l.mutex.Unlock()
+ if err != nil {
+ t.Fatalf("encryptLockedInto: %v", err)
+ }
+ if len(ct) != n || &ct[0] != &dst[0] {
+ t.Fatalf("expected in-place encrypt len=%d got=%d alias=%v", n, len(ct), len(ct) > 0 && &ct[0] == &dst[0])
+ }
+ got, err := l.decrypt(ct)
+ if err != nil {
+ t.Fatalf("decrypt: %v", err)
+ }
+ if !bytes.Equal(got, plain) {
+ t.Fatalf("roundtrip mismatch got=%q", got)
+ }
+}
+
+func TestSealEncryptedHT1MatchesEncryptThenPack(t *testing.T) {
+ l := handshakeLink(t)
+ plain := []byte("seal-ht1-parity")
+
+ l.mutex.Lock()
+ legacy, err := l.encryptLocked(plain)
+ l.mutex.Unlock()
+ if err != nil {
+ t.Fatalf("encryptLocked: %v", err)
+ }
+ viaPack := &packet.Packet{
+ HeaderType: packet.HeaderType1,
+ PacketType: packet.PacketTypeData,
+ Context: packet.ContextNone,
+ DestinationType: DestTypeLink,
+ DestinationHash: l.linkID,
+ Data: legacy,
+ }
+ if err := viaPack.Pack(); err != nil {
+ t.Fatalf("Pack: %v", err)
+ }
+
+ sealed := &packet.Packet{
+ HeaderType: packet.HeaderType1,
+ PacketType: packet.PacketTypeData,
+ Context: packet.ContextNone,
+ DestinationType: DestTypeLink,
+ DestinationHash: l.linkID,
+ }
+ l.mutex.Lock()
+ if err := l.sealEncryptedHT1Locked(sealed, plain); err != nil {
+ l.mutex.Unlock()
+ t.Fatalf("sealEncryptedHT1Locked: %v", err)
+ }
+ l.mutex.Unlock()
+
+ if len(sealed.Raw) != len(viaPack.Raw) {
+ t.Fatalf("len sealed=%d pack=%d", len(sealed.Raw), len(viaPack.Raw))
+ }
+ if sealed.Raw[0] != viaPack.Raw[0] || sealed.Raw[1] != viaPack.Raw[1] {
+ t.Fatalf("header mismatch sealed=%x pack=%x", sealed.Raw[:2], viaPack.Raw[:2])
+ }
+ if !bytes.Equal(sealed.Raw[2:packet.HeaderType1Overhead], viaPack.Raw[2:packet.HeaderType1Overhead]) {
+ t.Fatal("dest/context mismatch")
+ }
+ got, err := l.decrypt(sealed.Data)
+ if err != nil {
+ t.Fatalf("decrypt sealed: %v", err)
+ }
+ if !bytes.Equal(got, plain) {
+ t.Fatalf("sealed plaintext mismatch")
+ }
+}
+
+func TestEncryptLockedAllocBudget(t *testing.T) {
+ l := handshakeLink(t)
+ plain := bytes.Repeat([]byte{0x42}, 64)
+ n := encryptedPayloadLen(len(plain))
+ dst := make([]byte, n)
+
+ allocs := testing.AllocsPerRun(50, func() {
+ l.mutex.Lock()
+ _, err := l.encryptLockedInto(plain, dst)
+ l.mutex.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ if allocs > 1 {
+ t.Fatalf("encryptLockedInto allocs=%.1f want <= 1", allocs)
+ }
+
+ allocs = testing.AllocsPerRun(50, func() {
+ l.mutex.Lock()
+ _, err := l.encryptLocked(plain)
+ l.mutex.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ if allocs > 3 {
+ t.Fatalf("encryptLocked allocs=%.1f want <= 3", allocs)
+ }
+}
+
+func TestEncryptedPayloadLenMatchesPKCS7HMAC(t *testing.T) {
+ for _, n := range []int{0, 1, 15, 16, 17, 64, 200} {
+ padding := aes.BlockSize - n%aes.BlockSize
+ want := aes.BlockSize + n + padding + sha256.Size
+ if got := encryptedPayloadLen(n); got != want {
+ t.Fatalf("n=%d got=%d want=%d", n, got, want)
+ }
+ }
+}
+
+func BenchmarkEncryptLocked(b *testing.B) {
+ l := handshakeLink(b)
+ plain := bytes.Repeat([]byte{0x11}, 128)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ l.mutex.Lock()
+ _, err := l.encryptLocked(plain)
+ l.mutex.Unlock()
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkEncryptLockedInto(b *testing.B) {
+ l := handshakeLink(b)
+ plain := bytes.Repeat([]byte{0x11}, 128)
+ dst := make([]byte, encryptedPayloadLen(len(plain)))
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ l.mutex.Lock()
+ _, err := l.encryptLockedInto(plain, dst)
+ l.mutex.Unlock()
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkSealEncryptedHT1(b *testing.B) {
+ l := handshakeLink(b)
+ plain := bytes.Repeat([]byte{0x11}, 128)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p := &packet.Packet{
+ HeaderType: packet.HeaderType1,
+ PacketType: packet.PacketTypeData,
+ Context: packet.ContextNone,
+ DestinationType: DestTypeLink,
+ DestinationHash: l.linkID,
+ }
+ l.mutex.Lock()
+ err := l.sealEncryptedHT1Locked(p, plain)
+ l.mutex.Unlock()
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}

diff --git a/pkg/link/link.go b/pkg/link/link.go
index c2d77a0f..7798535b 100644
--- a/pkg/link/link.go
+++ b/pkg/link/link.go
@@ -13,6 +13,7 @@ import (
"crypto/sha256"
"errors"
"fmt"
+ "hash"
"io"
"sync"
"sync/atomic"
@@ -62,6 +63,10 @@ type Link struct {
remoteIdentity *identity.Identity
sessionKey *securemem.Buf
aesBlock cipher.Block
+ hmacSend hash.Hash
+ hmacRecv hash.Hash
+ hmacSendMu sync.Mutex
+ hmacRecvMu sync.Mutex
linkID []byte
rtt float64
@@ -990,24 +995,13 @@ func (l *Link) SendPacket(data []byte) error {
func (l *Link) SendPacketWithContext(data []byte, context byte) error {
l.mutex.Lock()
- defer l.mutex.Unlock()
if l.status.Load() != int32(StatusActive) {
- debug.Log(debug.DebugInfo, "Cannot send packet: link not active", "status", l.status.Load())
- return common.ErrLinkNotActive
- }
-
- debug.Log(debug.DebugVerbose, "Encrypting packet", "bytes", len(data), "context", fmt.Sprintf("0x%02x", context))
- var wireData []byte
- var err error
- if context == packet.ContextResource || context == packet.ContextCacheReq {
- wireData = data
- } else {
- wireData, err = l.encryptLocked(data)
- if err != nil {
- debug.Log(debug.DebugInfo, "Failed to encrypt packet", "error", err)
- return err
+ l.mutex.Unlock()
+ if debug.Enabled(debug.DebugInfo) {
+ debug.Log(debug.DebugInfo, "Cannot send packet: link not active", "status", l.status.Load())
}
+ return common.ErrLinkNotActive
}
p := &packet.Packet{
@@ -1019,21 +1013,55 @@ func (l *Link) SendPacketWithContext(data []byte, context byte) error {
Hops: 0,
DestinationType: DestTypeLink,
DestinationHash: l.linkID,
- Data: wireData,
CreateReceipt: true,
Link: l,
}
- if err := p.Pack(); err != nil {
+ var err error
+ if context == packet.ContextResource || context == packet.ContextCacheReq {
+ p.Data = data
+ err = p.Pack()
+ } else {
+ err = l.sealEncryptedHT1Locked(p, data)
+ }
+ if err != nil {
+ l.mutex.Unlock()
+ if debug.Enabled(debug.DebugInfo) {
+ debug.Log(debug.DebugInfo, "Failed to encrypt packet", "error", err)
+ }
return err
}
- debug.Log(debug.DebugVerbose, "Sending encrypted packet", "bytes", len(wireData))
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Sending encrypted packet", "bytes", len(p.Data), "context", context)
+ }
l.recordOutboundData()
+ l.mutex.Unlock()
return l.transport.SendPacket(p)
}
+func encryptedPayloadLen(plainLen int) int {
+ padding := aes.BlockSize - plainLen%aes.BlockSize
+ return aes.BlockSize + plainLen + padding + sha256.Size
+}
+
+func (l *Link) sealEncryptedHT1Locked(p *packet.Packet, data []byte) error {
+ n := encryptedPayloadLen(len(data))
+ payload, err := p.PrepareHT1Buffer(l.linkID, n)
+ if err != nil {
+ return err
+ }
+ out, err := l.encryptLockedInto(data, payload)
+ if err != nil {
+ return err
+ }
+ if len(out) != n {
+ return errors.New("encrypted payload length mismatch")
+ }
+ return p.CommitPacked()
+}
+
func (l *Link) HandleInbound(pkt *packet.Packet) error {
if pkt.PacketType == packet.PacketTypeData {
l.mutex.Lock()
@@ -2306,7 +2334,14 @@ func maxFloat(a, b float64) float64 {
}
func encryptWithKeys(sessionKey, hmacKey, data []byte, block cipher.Block) ([]byte, error) {
- if sessionKey == nil || hmacKey == nil {
+ return encryptWithKeysInto(sessionKey, hmacKey, data, block, nil, nil)
+}
+
+func encryptWithKeysInto(sessionKey, hmacKey, data []byte, block cipher.Block, mac hash.Hash, dst []byte) ([]byte, error) {
+ if block == nil && len(sessionKey) == 0 {
+ return nil, errors.New("no session keys available")
+ }
+ if mac == nil && len(hmacKey) == 0 {
return nil, errors.New("no session keys available")
}
@@ -2320,27 +2355,39 @@ func encryptWithKeys(sessionKey, hmacKey, data []byte, block cipher.Block) ([]by
padding := aes.BlockSize - len(data)%aes.BlockSize
ctLen := len(data) + padding
- result := make([]byte, aes.BlockSize+ctLen+sha256.Size)
- if _, err := io.ReadFull(rand.Reader, result[:aes.BlockSize]); err != nil {
+ n := aes.BlockSize + ctLen + sha256.Size
+ if cap(dst) < n {
+ dst = make([]byte, n)
+ } else {
+ dst = dst[:n]
+ }
+ if _, err := io.ReadFull(rand.Reader, dst[:aes.BlockSize]); err != nil {
return nil, err
}
- copy(result[aes.BlockSize:aes.BlockSize+len(data)], data)
+ copy(dst[aes.BlockSize:aes.BlockSize+len(data)], data)
padByte := byte(padding)
for i := aes.BlockSize + len(data); i < aes.BlockSize+ctLen; i++ {
- result[i] = padByte
+ dst[i] = padByte
+ }
+ if err := cryptography.EncryptCBC(block, dst[:aes.BlockSize], dst[aes.BlockSize:aes.BlockSize+ctLen]); err != nil {
+ return nil, err
}
- mode := cipher.NewCBCEncrypter(block, result[:aes.BlockSize]) // #nosec G407
- ct := result[aes.BlockSize : aes.BlockSize+ctLen]
- mode.CryptBlocks(ct, ct)
-
- h := hmac.New(sha256.New, hmacKey)
- h.Write(result[:aes.BlockSize+ctLen])
- return h.Sum(result[:aes.BlockSize+ctLen]), nil
+ signed := dst[:aes.BlockSize+ctLen]
+ if mac == nil {
+ mac = hmac.New(sha256.New, hmacKey)
+ } else {
+ mac.Reset()
+ }
+ mac.Write(signed)
+ return mac.Sum(signed), nil
}
-func decryptWithKeys(sessionKey, hmacKey, data []byte, block cipher.Block) ([]byte, error) {
- if sessionKey == nil || hmacKey == nil {
+func decryptWithKeys(sessionKey, hmacKey, data []byte, block cipher.Block, mac hash.Hash) ([]byte, error) {
+ if block == nil && len(sessionKey) == 0 {
+ return nil, errors.New("no session keys available")
+ }
+ if mac == nil && len(hmacKey) == 0 {
return nil, errors.New("no session keys available")
}
if len(data) < aes.BlockSize+aes.BlockSize+32 {
@@ -2350,10 +2397,15 @@ func decryptWithKeys(sessionKey, hmacKey, data []byte, block cipher.Block) ([]by
signedParts := data[:len(data)-32]
receivedMac := data[len(data)-32:]
- h := hmac.New(sha256.New, hmacKey)
- h.Write(signedParts)
- expectedMac := h.Sum(nil)
- if !hmac.Equal(receivedMac, expectedMac) {
+ var expected [sha256.Size]byte
+ if mac == nil {
+ mac = hmac.New(sha256.New, hmacKey)
+ } else {
+ mac.Reset()
+ }
+ mac.Write(signedParts)
+ mac.Sum(expected[:0])
+ if !hmac.Equal(receivedMac, expected[:]) {
return nil, errHMACVerificationFailed
}
@@ -2372,9 +2424,10 @@ func decryptWithKeys(sessionKey, hmacKey, data []byte, block cipher.Block) ([]by
if len(ciphertext)%aes.BlockSize != 0 {
return nil, errors.New("ciphertext is not a multiple of the block size")
}
- mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
- mode.CryptBlocks(plaintext, ciphertext)
+ if err := cryptography.DecryptCBC(block, iv, ciphertext, plaintext); err != nil {
+ return nil, err
+ }
return cryptography.RemovePKCS7Padding(plaintext)
}
@@ -2398,16 +2451,24 @@ func snapshotSessionKeysLocked(l *Link, sessionDst, hmacDst []byte) bool {
func (l *Link) refreshAESBlockLocked() {
l.aesBlock = nil
- if l.sessionKey == nil {
+ l.hmacSendMu.Lock()
+ l.hmacSend = nil
+ l.hmacSendMu.Unlock()
+ l.hmacRecvMu.Lock()
+ l.hmacRecv = nil
+ l.hmacRecvMu.Unlock()
+ if l.sessionKey == nil || l.hmacKey == nil {
return
}
sk := bufBytes(l.sessionKey)
+ hk := bufBytes(l.hmacKey)
var key []byte
if l.mode == ModeAES128CBC {
- if len(sk) < 16 {
+ if len(sk) < 16 || len(hk) < 16 {
return
}
key = sk[:16]
+ hk = hk[:16]
} else if len(sk) >= 32 {
key = sk[:32]
} else if len(sk) >= 16 {
@@ -2415,23 +2476,42 @@ func (l *Link) refreshAESBlockLocked() {
} else {
return
}
+ if len(hk) == 0 {
+ return
+ }
block, err := aes.NewCipher(key)
if err != nil {
return
}
l.aesBlock = block
+ l.hmacSendMu.Lock()
+ l.hmacSend = hmac.New(sha256.New, hk)
+ l.hmacSendMu.Unlock()
+ l.hmacRecvMu.Lock()
+ l.hmacRecv = hmac.New(sha256.New, hk)
+ l.hmacRecvMu.Unlock()
}
func (l *Link) encrypt(data []byte) ([]byte, error) {
- var sessionKey, hmacKey [32]byte
l.mutex.RLock()
- ok := snapshotSessionKeysLocked(l, sessionKey[:], hmacKey[:])
- mode := l.mode
block := l.aesBlock
+ mac := l.hmacSend
+ mode := l.mode
+ var sessionKey, hmacKey [32]byte
+ ok := block != nil && mac != nil
+ if !ok {
+ ok = snapshotSessionKeysLocked(l, sessionKey[:], hmacKey[:])
+ }
l.mutex.RUnlock()
if !ok {
return nil, errors.New("no session keys available")
}
+ if block != nil && mac != nil {
+ l.hmacSendMu.Lock()
+ out, err := encryptWithKeysInto(nil, nil, data, block, mac, nil)
+ l.hmacSendMu.Unlock()
+ return out, err
+ }
defer securemem.WipeBytes(sessionKey[:])
defer securemem.WipeBytes(hmacKey[:])
if mode == ModeAES128CBC {
@@ -2440,8 +2520,13 @@ func (l *Link) encrypt(data []byte) ([]byte, error) {
return encryptWithKeys(sessionKey[:], hmacKey[:], data, block)
}
-// encryptLocked encrypts data while the link mutex is already held by the caller.
-func (l *Link) encryptLocked(data []byte) ([]byte, error) {
+func (l *Link) encryptLockedInto(data, dst []byte) ([]byte, error) {
+ if l.aesBlock != nil && l.hmacSend != nil {
+ l.hmacSendMu.Lock()
+ out, err := encryptWithKeysInto(nil, nil, data, l.aesBlock, l.hmacSend, dst)
+ l.hmacSendMu.Unlock()
+ return out, err
+ }
var sessionKey, hmacKey [32]byte
if !snapshotSessionKeysLocked(l, sessionKey[:], hmacKey[:]) {
return nil, errors.New("no session keys available")
@@ -2449,9 +2534,14 @@ func (l *Link) encryptLocked(data []byte) ([]byte, error) {
defer securemem.WipeBytes(sessionKey[:])
defer securemem.WipeBytes(hmacKey[:])
if l.mode == ModeAES128CBC {
- return encryptWithKeys(sessionKey[:16], hmacKey[:16], data, l.aesBlock)
+ return encryptWithKeysInto(sessionKey[:16], hmacKey[:16], data, l.aesBlock, nil, dst)
}
- return encryptWithKeys(sessionKey[:], hmacKey[:], data, l.aesBlock)
+ return encryptWithKeysInto(sessionKey[:], hmacKey[:], data, l.aesBlock, nil, dst)
+}
+
+// encryptLocked encrypts data while the link mutex is already held by the caller.
+func (l *Link) encryptLocked(data []byte) ([]byte, error) {
+ return l.encryptLockedInto(data, nil)
}
func (l *Link) decrypt(data []byte) ([]byte, error) {
@@ -2464,25 +2554,37 @@ func (l *Link) decrypt(data []byte) ([]byte, error) {
return nil, errors.New("dos_protection refused crypto")
}
defer release()
- var sessionKey, hmacKey [32]byte
l.mutex.RLock()
- ok := snapshotSessionKeysLocked(l, sessionKey[:], hmacKey[:])
- mode := l.mode
block := l.aesBlock
+ mac := l.hmacRecv
+ mode := l.mode
+ var sessionKey, hmacKey [32]byte
+ ok := block != nil && mac != nil
+ if !ok {
+ ok = snapshotSessionKeysLocked(l, sessionKey[:], hmacKey[:])
+ }
l.mutex.RUnlock()
if !ok {
debug.Log(debug.DebugError, "Decrypt failed: no session keys", "link_id", fmt.Sprintf("%x", l.linkID))
return nil, errors.New("no session keys available")
}
- defer securemem.WipeBytes(sessionKey[:])
- defer securemem.WipeBytes(hmacKey[:])
- sk, hk := sessionKey[:], hmacKey[:]
- if mode == ModeAES128CBC {
- sk = sessionKey[:16]
- hk = hmacKey[:16]
+ var plaintext []byte
+ var err error
+ if block != nil && mac != nil {
+ l.hmacRecvMu.Lock()
+ plaintext, err = decryptWithKeys(nil, nil, data, block, mac)
+ l.hmacRecvMu.Unlock()
+ } else {
+ defer securemem.WipeBytes(sessionKey[:])
+ defer securemem.WipeBytes(hmacKey[:])
+ sk, hk := sessionKey[:], hmacKey[:]
+ if mode == ModeAES128CBC {
+ sk = sessionKey[:16]
+ hk = hmacKey[:16]
+ }
+ plaintext, err = decryptWithKeys(sk, hk, data, block, nil)
}
- plaintext, err := decryptWithKeys(sk, hk, data, block)
if err != nil {
ifaceName := l.attachedIfaceName()
switch {
@@ -2580,22 +2682,23 @@ func (l *Link) Send(data []byte) any {
Hops: 0,
DestinationType: DestTypeLink,
DestinationHash: l.linkID,
- Data: data,
CreateReceipt: false,
Link: l,
}
- encrypted, err := l.encrypt(data)
- if err != nil {
+ l.mutex.Lock()
+ if l.status.Load() != int32(StatusActive) {
+ l.mutex.Unlock()
+ debug.Log(debug.DebugInfo, common.MsgLinkNotActive)
return nil
}
- pkt.Data = encrypted
-
- if err := pkt.Pack(); err != nil {
+ if err := l.sealEncryptedHT1Locked(pkt, data); err != nil {
+ l.mutex.Unlock()
return nil
}
-
l.recordOutbound()
+ l.mutex.Unlock()
+
if err := l.transport.SendPacket(pkt); err != nil {
return nil
}

diff --git a/pkg/packet/constants.go b/pkg/packet/constants.go
index 65916c1c..383c6119 100644
--- a/pkg/packet/constants.go
+++ b/pkg/packet/constants.go
@@ -78,6 +78,9 @@ const (
HeaderMaxSize = 64
MTU = 500
+ // HeaderType1Overhead is flags + hops + dest hash + context.
+ HeaderType1Overhead = 2 + TruncatedHashLength + 1
+
MaxInboundPacketSize = 262144
AddressSize = 32

diff --git a/pkg/packet/pack_ht1_test.go b/pkg/packet/pack_ht1_test.go
new file mode 100644
index 00000000..8e95bb67
--- /dev/null
+++ b/pkg/packet/pack_ht1_test.go
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package packet
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestPrepareHT1BufferMatchesPack(t *testing.T) {
+ dest := bytes.Repeat([]byte{0xAB}, TruncatedHashLength)
+ payload := []byte("ht1-payload")
+
+ viaPack := &Packet{
+ HeaderType: HeaderType1,
+ PacketType: PacketTypeData,
+ TransportType: 0,
+ Context: ContextNone,
+ ContextFlag: FlagUnset,
+ Hops: 0,
+ DestinationType: DestinationLink,
+ DestinationHash: dest,
+ Data: payload,
+ }
+ if err := viaPack.Pack(); err != nil {
+ t.Fatalf("Pack: %v", err)
+ }
+
+ viaPrep := &Packet{
+ HeaderType: HeaderType1,
+ PacketType: PacketTypeData,
+ TransportType: 0,
+ Context: ContextNone,
+ ContextFlag: FlagUnset,
+ Hops: 0,
+ DestinationType: DestinationLink,
+ DestinationHash: dest,
+ }
+ buf, err := viaPrep.PrepareHT1Buffer(dest, len(payload))
+ if err != nil {
+ t.Fatalf("PrepareHT1Buffer: %v", err)
+ }
+ copy(buf, payload)
+ if err := viaPrep.CommitPacked(); err != nil {
+ t.Fatalf("CommitPacked: %v", err)
+ }
+
+ if !bytes.Equal(viaPack.Raw, viaPrep.Raw) {
+ t.Fatalf("raw mismatch\npack %x\nprep %x", viaPack.Raw, viaPrep.Raw)
+ }
+ if !bytes.Equal(viaPack.GetHash(), viaPrep.GetHash()) {
+ t.Fatalf("hash mismatch")
+ }
+ if HeaderType1Overhead != 2+TruncatedHashLength+1 {
+ t.Fatalf("HeaderType1Overhead=%d", HeaderType1Overhead)
+ }
+}
+
+func TestPrepareHT1BufferRejectsOversize(t *testing.T) {
+ p := &Packet{HeaderType: HeaderType1, DestinationType: DestinationLink}
+ if _, err := p.PrepareHT1Buffer(bytes.Repeat([]byte{0x01}, TruncatedHashLength), MTU); err == nil {
+ t.Fatal("expected MTU error")
+ }
+}

diff --git a/pkg/packet/packet.go b/pkg/packet/packet.go
index f5859704..30c168e0 100644
--- a/pkg/packet/packet.go
+++ b/pkg/packet/packet.go
@@ -127,12 +127,24 @@ func NewPacket(destType byte, data []byte, packetType byte, context byte,
}
}
+func (p *Packet) headerFlags() byte {
+ flags := byte(0)
+ flags |= (p.HeaderType << 6) & HeaderMaskHeaderType
+ flags |= (p.ContextFlag << 5) & HeaderMaskContextFlag
+ flags |= (p.TransportType << 4) & HeaderMaskTransportType
+ flags |= (p.DestinationType << 2) & HeaderMaskDestinationType
+ flags |= p.PacketType & HeaderMaskPacketType
+ return flags
+}
+
func (p *Packet) Pack() error {
if p.Packed {
return nil
}
- debug.Log(debug.DebugPackets, "Packing packet", "type", p.PacketType, "header", p.HeaderType)
+ if debug.Enabled(debug.DebugPackets) {
+ debug.Log(debug.DebugPackets, "Packing packet", "type", p.PacketType, "header", p.HeaderType)
+ }
if n := len(p.DestinationHash); n != 0 && n != TruncatedHashLength {
return fmt.Errorf("destination hash must be %d bytes, got %d", TruncatedHashLength, n)
@@ -146,21 +158,16 @@ func (p *Packet) Pack() error {
}
}
- flags := byte(0)
- flags |= (p.HeaderType << 6) & HeaderMaskHeaderType
- flags |= (p.ContextFlag << 5) & HeaderMaskContextFlag
- flags |= (p.TransportType << 4) & HeaderMaskTransportType
- flags |= (p.DestinationType << 2) & HeaderMaskDestinationType
- flags |= p.PacketType & HeaderMaskPacketType
+ flags := p.headerFlags()
- if debug.GetDebugLevel() >= debug.DebugTrace {
+ if debug.Enabled(debug.DebugTrace) {
debug.Log(debug.DebugTrace, "Created packet header", "flags", fmt.Sprintf("%08b", flags), "hops", p.Hops)
}
need := 2 + len(p.DestinationHash) + 1 + len(p.Data)
if p.HeaderType == HeaderType2 {
need += len(p.TransportID)
- if debug.GetDebugLevel() >= debug.DebugAll {
+ if debug.Enabled(debug.DebugAll) {
debug.Log(debug.DebugAll, "Added transport ID to header", "transport_id", fmt.Sprintf("%x", p.TransportID))
}
}
@@ -199,12 +206,16 @@ func (p *Packet) Pack() error {
raw = append(raw, payload...)
p.Raw = raw
- hdrLen := 2 + len(destHash) + 1
- if p.HeaderType == HeaderType2 {
- hdrLen += len(transportID)
+ if debug.Enabled(debug.DebugPackets) {
+ hdrLen := 2 + len(destHash) + 1
+ if p.HeaderType == HeaderType2 {
+ hdrLen += len(transportID)
+ }
+ debug.Log(debug.DebugPackets, "Final header length", "bytes", hdrLen)
+ }
+ if debug.Enabled(debug.DebugTrace) {
+ debug.Log(debug.DebugTrace, "Final packet size", "bytes", len(p.Raw))
}
- debug.Log(debug.DebugPackets, "Final header length", "bytes", hdrLen)
- debug.Log(debug.DebugTrace, "Final packet size", "bytes", len(p.Raw))
if len(p.Raw) > MTU {
return errors.New("packet size exceeds MTU")
@@ -219,6 +230,49 @@ func (p *Packet) Pack() error {
return nil
}
+// PrepareHT1Buffer writes the header type 1 prefix into Raw and returns the
+// payload region for in-place encryption or a copy. destHash must be
+// TruncatedHashLength bytes and must not alias Raw.
+func (p *Packet) PrepareHT1Buffer(destHash []byte, payloadLen int) ([]byte, error) {
+ if len(destHash) != TruncatedHashLength {
+ return nil, fmt.Errorf("destination hash must be %d bytes, got %d", TruncatedHashLength, len(destHash))
+ }
+ if p.HeaderType != HeaderType1 {
+ return nil, errors.New("PrepareHT1Buffer requires header type 1")
+ }
+ need := HeaderType1Overhead + payloadLen
+ if need > MTU {
+ return nil, errors.New("packet size exceeds MTU")
+ }
+ raw := p.Raw
+ if cap(raw) < need {
+ raw = make([]byte, need, nextRawWireCap(need))
+ } else {
+ raw = raw[:need]
+ }
+ raw[0] = p.headerFlags()
+ raw[1] = p.Hops
+ copy(raw[2:2+TruncatedHashLength], destHash)
+ raw[2+TruncatedHashLength] = p.Context
+ p.Raw = raw
+ p.DestinationHash = destHash
+ p.Data = raw[HeaderType1Overhead:]
+ p.Packed = false
+ p.hashValid = false
+ return p.Data, nil
+}
+
+// CommitPacked hashes a Raw buffer previously filled by PrepareHT1Buffer.
+func (p *Packet) CommitPacked() error {
+ if len(p.Raw) > MTU {
+ return errors.New("packet size exceeds MTU")
+ }
+ p.Packed = true
+ p.hashValid = false
+ p.updateHash()
+ return nil
+}
+
func (p *Packet) Unpack() error {
if len(p.Raw) < MinPacketSize {
return errors.New("packet too short")

diff --git a/pkg/packet/receipt.go b/pkg/packet/receipt.go
index a617df4b..eaac87e3 100644
--- a/pkg/packet/receipt.go
+++ b/pkg/packet/receipt.go
@@ -44,28 +44,29 @@ type PacketReceipt struct {
link any
destinationIdent *identity.Identity
- timeoutCheckDone chan bool
+ timer *time.Timer
}
-// NewPacketReceipt creates a receipt for the given packet and starts the timeout watchdog.
+// NewPacketReceipt creates a receipt for the given packet and starts its timeout timer.
func NewPacketReceipt(pkt *Packet) *PacketReceipt {
hash := append([]byte(nil), pkt.Hash()...)
+ timeout := calculateTimeout(pkt)
receipt := &PacketReceipt{
- hash: hash,
- truncatedHash: pkt.TruncatedHash(),
- sent: true,
- sentAt: time.Now(),
- proved: false,
- status: ReceiptSent,
- destination: pkt.Destination,
- link: pkt.Link,
- timeout: calculateTimeout(pkt),
- timeoutCheckDone: make(chan bool, 1),
+ hash: hash,
+ truncatedHash: hash[:TruncatedHashLength],
+ sent: true,
+ sentAt: time.Now(),
+ proved: false,
+ status: ReceiptSent,
+ destination: pkt.Destination,
+ link: pkt.Link,
+ timeout: timeout,
}
+ receipt.timer = time.AfterFunc(timeout, receipt.onTimeout)
- go receipt.timeoutWatchdog()
-
- debug.Log(debug.DebugPackets, "Created packet receipt", "hash", fmt.Sprintf("%x", receipt.truncatedHash))
+ if debug.Enabled(debug.DebugPackets) {
+ debug.Log(debug.DebugPackets, "Created packet receipt", "hash", fmt.Sprintf("%x", receipt.truncatedHash))
+ }
return receipt
}
@@ -146,6 +147,7 @@ func (pr *PacketReceipt) ValidateLinkProof(proof []byte, link any, proofPacket *
pr.concludedAt = time.Now()
pr.proofPacket = proofPacket
callback := pr.deliveryCallback
+ pr.stopTimerLocked()
pr.mutex.Unlock()
if callback != nil {
@@ -193,6 +195,7 @@ func (pr *PacketReceipt) ValidateProof(proof []byte, proofPacket *Packet) bool {
pr.concludedAt = time.Now()
pr.proofPacket = proofPacket
callback := pr.deliveryCallback
+ pr.stopTimerLocked()
pr.mutex.Unlock()
if callback != nil {
@@ -221,6 +224,7 @@ func (pr *PacketReceipt) ValidateProof(proof []byte, proofPacket *Packet) bool {
pr.concludedAt = time.Now()
pr.proofPacket = proofPacket
callback := pr.deliveryCallback
+ pr.stopTimerLocked()
pr.mutex.Unlock()
if callback != nil {
@@ -275,6 +279,7 @@ func (pr *PacketReceipt) checkTimeout() {
}
if time.Since(pr.sentAt) <= pr.timeout {
+ pr.rescheduleTimerLocked()
pr.mutex.Unlock()
return
}
@@ -287,41 +292,43 @@ func (pr *PacketReceipt) checkTimeout() {
pr.concludedAt = time.Now()
callback := pr.timeoutCallback
+ pr.stopTimerLocked()
pr.mutex.Unlock()
- debug.Log(debug.DebugVerbose, "Packet receipt timed out", "hash", fmt.Sprintf("%x", pr.truncatedHash))
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Packet receipt timed out", "hash", fmt.Sprintf("%x", pr.truncatedHash))
+ }
if callback != nil {
go callback(pr)
}
}
-func (pr *PacketReceipt) timeoutWatchdog() {
- ticker := time.NewTicker(1 * time.Second)
- defer ticker.Stop()
-
- for {
- select {
- case <-ticker.C:
- pr.checkTimeout()
+func (pr *PacketReceipt) onTimeout() {
+ pr.checkTimeout()
+}
- pr.mutex.RLock()
- status := pr.status
- pr.mutex.RUnlock()
+func (pr *PacketReceipt) stopTimerLocked() {
+ if pr.timer != nil {
+ pr.timer.Stop()
+ }
+}
- if status != ReceiptSent {
- return
- }
- case <-pr.timeoutCheckDone:
- return
- }
+func (pr *PacketReceipt) rescheduleTimerLocked() {
+ if pr.timer == nil {
+ return
}
+ remaining := max(pr.timeout-time.Since(pr.sentAt), 0)
+ pr.timer.Reset(remaining)
}
func (pr *PacketReceipt) SetTimeout(timeout time.Duration) {
pr.mutex.Lock()
defer pr.mutex.Unlock()
pr.timeout = timeout
+ if pr.status == ReceiptSent {
+ pr.rescheduleTimerLocked()
+ }
}
func (pr *PacketReceipt) SetDeliveryCallback(callback func(*PacketReceipt)) {
@@ -356,9 +363,5 @@ func (pr *PacketReceipt) Cancel() {
pr.status = ReceiptCulled
pr.concludedAt = time.Now()
}
-
- select {
- case pr.timeoutCheckDone <- true:
- default:
- }
+ pr.stopTimerLocked()
}

diff --git a/pkg/sandbox/sandbox_unix.go b/pkg/sandbox/sandbox_unix.go
index fd4ddfd5..963611cf 100644
--- a/pkg/sandbox/sandbox_unix.go
+++ b/pkg/sandbox/sandbox_unix.go
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
-//go:build unix && !linux && !darwin && !openbsd && !freebsd && !haiku
+//go:build unix && !linux && !darwin && !openbsd && !freebsd && !haiku && !solaris && !illumos && !aix
package sandbox

diff --git a/pkg/sandbox/sandbox_unix_nonproc.go b/pkg/sandbox/sandbox_unix_nonproc.go
new file mode 100644
index 00000000..93decb7c
--- /dev/null
+++ b/pkg/sandbox/sandbox_unix_nonproc.go
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+//go:build solaris || illumos || aix
+
+package sandbox
+
+import (
+ "golang.org/x/sys/unix"
+ "quad4/reticulum-go/pkg/common"
+ "quad4/reticulum-go/pkg/debug"
+)
+
+func applyPlatform(cfg *common.ReticulumConfig) error {
+ if err := setResourceLimits(); err != nil {
+ debug.Log(debug.DebugError, "Setrlimit failed", "error", err)
+ }
+
+ debug.Log(debug.DebugInfo, "Sandbox applied", "platform", "unix-no-nproc")
+ return nil
+}
+
+func setResourceLimits() error {
+ const maxFDs = 65536
+ if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &unix.Rlimit{Cur: maxFDs, Max: maxFDs}); err != nil {
+ debug.Log(debug.DebugError, "RLIMIT_NOFILE failed", "error", err)
+ }
+
+ if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{Cur: 0, Max: 0}); err != nil {
+ debug.Log(debug.DebugError, "RLIMIT_CORE failed", "error", err)
+ }
+
+ return nil
+}

diff --git a/pkg/transport/handler_pool_test.go b/pkg/transport/handler_pool_test.go
index 1d07735e..92ae254f 100644
--- a/pkg/transport/handler_pool_test.go
+++ b/pkg/transport/handler_pool_test.go
@@ -10,11 +10,78 @@ import (
"time"
"quad4/reticulum-go/pkg/common"
+ "quad4/reticulum-go/pkg/debug"
"quad4/reticulum-go/pkg/health"
"quad4/reticulum-go/pkg/protect"
)
+func silenceHandlerPoolLogs(t *testing.T) {
+ t.Helper()
+ prev := debug.GetDebugLevel()
+ debug.SetDebugLevel(debug.DebugCritical)
+ t.Cleanup(func() { debug.SetDebugLevel(prev) })
+}
+
+func TestHandlerPoolStartsSmall(t *testing.T) {
+ silenceHandlerPoolLogs(t)
+ tr := NewTransport(&common.ReticulumConfig{
+ EnableTransport: true,
+ MaxPacketHandlers: common.DefaultMaxPacketHandlers,
+ })
+ t.Cleanup(func() { _ = tr.Close() })
+
+ iface := common.NewBaseInterface("idle0", common.IFTypeUDP, true)
+ tr.HandlePacket([]byte{0x00, 0x00, 0x01}, &iface)
+ time.Sleep(20 * time.Millisecond)
+
+ live := int(tr.handlerLive.Load())
+ boot := startupHandlerCount(common.DefaultMaxPacketHandlers)
+ if live < 1 {
+ t.Fatal("no packet handlers started")
+ }
+ if live > boot+8 {
+ t.Fatalf("idle handlers=%d want around %d not %d", live, boot, common.DefaultMaxPacketHandlers)
+ }
+}
+
+func TestStartupHandlerCountCapsAtMax(t *testing.T) {
+ if got := startupHandlerCount(2); got != 2 {
+ t.Fatalf("startupHandlerCount(2)=%d", got)
+ }
+ if got := startupHandlerCount(common.DefaultMaxPacketHandlers); got > common.DefaultMaxPacketHandlers {
+ t.Fatalf("startup=%d exceeds max", got)
+ }
+ if runtime.GOMAXPROCS(0) < common.DefaultMaxPacketHandlers {
+ if got := startupHandlerCount(common.DefaultMaxPacketHandlers); got >= common.DefaultMaxPacketHandlers {
+ t.Fatalf("startup=%d should be well below default max", got)
+ }
+ }
+}
+
+func TestHandlerPoolDoesNotRampOnLightLoad(t *testing.T) {
+ silenceHandlerPoolLogs(t)
+ tr := NewTransport(&common.ReticulumConfig{
+ EnableTransport: true,
+ MaxPacketHandlers: common.DefaultMaxPacketHandlers,
+ })
+ t.Cleanup(func() { _ = tr.Close() })
+
+ iface := common.NewBaseInterface("light0", common.IFTypeUDP, true)
+ pkt := []byte{0x00, 0x00, 0x01}
+ for range 256 {
+ tr.HandlePacket(pkt, &iface)
+ }
+ time.Sleep(20 * time.Millisecond)
+
+ live := int(tr.handlerLive.Load())
+ boot := startupHandlerCount(common.DefaultMaxPacketHandlers)
+ if live > boot+8 {
+ t.Fatalf("light-load handlers=%d boot=%d (must not ramp toward %d)", live, boot, common.DefaultMaxPacketHandlers)
+ }
+}
+
func TestHandlerPoolGoroutineBudget(t *testing.T) {
+ silenceHandlerPoolLogs(t)
const n = 8
tr := NewTransport(&common.ReticulumConfig{
EnableTransport: true,
@@ -59,6 +126,7 @@ func TestHandlerPoolCloseDoesNotDeadlock(t *testing.T) {
}
func TestHandlerPoolEnqueueCloseRace(t *testing.T) {
+ silenceHandlerPoolLogs(t)
tr := NewTransport(&common.ReticulumConfig{
EnableTransport: true,
MaxPacketHandlers: 4,

diff --git a/pkg/transport/packet_handler.go b/pkg/transport/packet_handler.go
index 07ca1f88..8b8b4281 100644
--- a/pkg/transport/packet_handler.go
+++ b/pkg/transport/packet_handler.go
@@ -5,6 +5,7 @@ package transport
import (
"fmt"
+ "runtime"
"quad4/reticulum-go/pkg/common"
"quad4/reticulum-go/pkg/debug"
@@ -30,22 +31,65 @@ func (t *Transport) startPacketWorkers(n int) {
t.packetQ = make(chan packetJob, n)
}
t.handlerWG.Add(n)
+ t.handlerLive.Add(int32(n))
for range n {
go t.packetWorker()
}
}
+func startupHandlerCount(maxN int) int {
+ if maxN < 1 {
+ maxN = common.DefaultMaxPacketHandlers
+ }
+ boot := max(runtime.GOMAXPROCS(0), 4)
+ if maxN < boot {
+ return maxN
+ }
+ return boot
+}
+
func (t *Transport) ensurePacketWorkers() {
t.handlerOnce.Do(func() {
+ if t.handlerClosed.Load() {
+ return
+ }
select {
case <-t.done:
return
default:
- t.startPacketWorkers(t.handlerN)
+ t.startPacketWorkers(startupHandlerCount(t.handlerN))
}
})
}
+func (t *Transport) growOneHandler() bool {
+ if t.handlerClosed.Load() {
+ return false
+ }
+ t.growMu.Lock()
+ defer t.growMu.Unlock()
+ if t.handlerClosed.Load() {
+ return false
+ }
+ select {
+ case <-t.done:
+ return false
+ default:
+ }
+ if int(t.handlerLive.Load()) >= t.handlerN {
+ return false
+ }
+ t.handlerWG.Add(1)
+ t.handlerLive.Add(1)
+ go t.packetWorker()
+ return true
+}
+
+func (t *Transport) growHandlersToMax() {
+ for t.growOneHandler() {
+ }
+}
+
func (t *Transport) packetWorker() {
defer t.handlerWG.Done()
for {
@@ -87,12 +131,20 @@ func (t *Transport) enqueuePacket(job packetJob) bool {
case t.packetQ <- job:
return true
default:
+ if t.growOneHandler() {
+ select {
+ case t.packetQ <- job:
+ return true
+ default:
+ }
+ }
return false
}
}
func (t *Transport) occupyHandlerPoolForTest(hold <-chan struct{}) int {
t.ensurePacketWorkers()
+ t.growHandlersToMax()
n := cap(t.packetQ)
if n < 1 {
return 0
@@ -125,7 +177,9 @@ func (t *Transport) dispatchInboundPacket(payload []byte, iface common.NetworkIn
}
pkt := &packet.Packet{Raw: payload}
if err := pkt.Unpack(); err != nil {
- debug.Log(debug.DebugInfo, "Failed to unpack proof packet", "error", err)
+ if debug.Enabled(debug.DebugInfo) {
+ debug.Log(debug.DebugInfo, "Failed to unpack proof packet", "error", err)
+ }
ifaceName := ""
if iface != nil {
ifaceName = iface.GetName()

diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go
index ccf9a29a..4e8bd52a 100644
--- a/pkg/transport/transport.go
+++ b/pkg/transport/transport.go
@@ -144,8 +144,11 @@ type Transport struct {
seenAnnounces map[[32]byte]time.Time
packetQ chan packetJob
handlerN int
+ handlerLive atomic.Int32
+ handlerClosed atomic.Bool
handlerOnce sync.Once
handlerWG sync.WaitGroup
+ growMu sync.Mutex
pendingAnnounceJobs []delayedAnnounceJob
pendingAnnounceMu sync.Mutex
pathfinder *pathfinder.PathFinder
@@ -971,7 +974,10 @@ func (t *Transport) Close() error {
t.stopOnce.Do(func() {
close(t.done)
})
+ t.handlerClosed.Store(true)
t.handlerOnce.Do(func() {})
+ t.growMu.Lock()
+ t.growMu.Unlock()
t.handlerWG.Wait()
if e := protect.Default(); e != nil {
@@ -1603,7 +1609,9 @@ func SendAnnounce(packet []byte) error {
func (t *Transport) HandlePacket(data []byte, iface common.NetworkInterface) {
if len(data) < 2 {
- debug.Log(debug.DebugVerbose, "Dropping packet: insufficient length", "bytes", len(data))
+ if debug.Enabled(debug.DebugVerbose) {
+ debug.Log(debug.DebugVerbose, "Dropping packet: insufficient length", "bytes", len(data))
+ }
return
}
@@ -2247,7 +2255,9 @@ func (t *Transport) handleTransportPacket(data []byte, iface common.NetworkInter
pkt := &packet.Packet{Raw: data}
if err := pkt.Unpack(); err != nil {
- debug.Log(debug.DebugInfo, "Failed to unpack transport packet", "error", err)
+ if debug.Enabled(debug.DebugInfo) {
+ debug.Log(debug.DebugInfo, "Failed to unpack transport packet", "error", err)
+ }
ifaceName := ""
if iface != nil {
ifaceName = iface.GetName()
@@ -2639,7 +2649,9 @@ func (t *Transport) SendPacket(p *packet.Packet) error {
debug.Log(debug.DebugInfo, "Packet serialization failed", "error", err)
return fmt.Errorf("failed to serialize packet: %w", err)
}
- debug.Log(debug.DebugTrace, "Serialized packet size", "bytes", len(data))
+ if debug.Enabled(debug.DebugTrace) {
+ debug.Log(debug.DebugTrace, "Serialized packet size", "bytes", len(data))
+ }
if debug.Enabled(debug.DebugTrace) {
debug.Log(debug.DebugTrace, "Using path", "interface", path.Interface.GetName(), "nextHop", fmt.Sprintf("%x", path.NextHop), "hops", path.HopCount)
@@ -2656,10 +2668,14 @@ func (t *Transport) SendPacket(p *packet.Packet) error {
if p.CreateReceipt {
receipt := packet.NewPacketReceipt(p)
t.RegisterReceipt(receipt)
- debug.Log(debug.DebugPackets, "Created packet receipt")
+ if debug.Enabled(debug.DebugPackets) {
+ debug.Log(debug.DebugPackets, "Created packet receipt")
+ }
}
- debug.Log(debug.DebugAll, "Packet sent successfully")
+ if debug.Enabled(debug.DebugAll) {
+ debug.Log(debug.DebugAll, "Packet sent successfully")
+ }
return nil
}

diff --git a/pkg/zenfix/analyze.go b/pkg/zenfix/analyze.go
index 7ad428ca..506a1489 100644
--- a/pkg/zenfix/analyze.go
+++ b/pkg/zenfix/analyze.go
@@ -8,10 +8,6 @@ import (
"go/ast"
"go/token"
"os"
- "path/filepath"
- "strings"
-
- "golang.org/x/tools/go/packages"
)
// Result holds findings and fix stats.
@@ -71,58 +67,22 @@ func InspectGoFile(fset *token.FileSet, path string, src any) ([]Finding, error)
}
func analyzeGo(opts Options, dir string) ([]Finding, error) {
- cfg := &packages.Config{
- Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedSyntax,
- Dir: dir,
- Tests: opts.Tests,
- }
- pkgs, err := packages.Load(cfg, opts.Patterns...)
+ files, err := listGoFiles(dir, opts.Patterns, opts.Tests)
if err != nil {
return nil, err
}
- if packages.PrintErrors(pkgs) > 0 {
- return nil, fmt.Errorf("package load failed")
- }
-
var out []Finding
- for _, pkg := range pkgs {
- if pkg.IllTyped {
+ for _, path := range files {
+ fset := token.NewFileSet()
+ file, err := parserParseFile(fset, path, nil)
+ if err != nil {
continue
}
- fset := pkg.Fset
- if fset == nil {
- fset = token.NewFileSet()
- }
- for i, file := range pkg.Syntax {
- if file == nil {
- continue
- }
- path := filePath(pkg, i)
- if path == "" {
- continue
- }
- if !opts.Tests && strings.HasSuffix(path, "_test.go") {
- continue
- }
- if strings.Contains(path, string(filepath.Separator)+"vendor"+string(filepath.Separator)) {
- continue
- }
- out = append(out, inspectFile(fset, path, file)...)
- }
+ out = append(out, inspectFile(fset, path, file)...)
}
return dedupeFindings(out), nil
}
-func filePath(pkg *packages.Package, i int) string {
- if i < len(pkg.CompiledGoFiles) {
- return pkg.CompiledGoFiles[i]
- }
- if i < len(pkg.GoFiles) {
- return pkg.GoFiles[i]
- }
- return ""
-}
-
type visitor struct {
fset *token.FileSet
file string

diff --git a/pkg/zenfix/compile_graph_test.go b/pkg/zenfix/compile_graph_test.go
new file mode 100644
index 00000000..3d8408d0
--- /dev/null
+++ b/pkg/zenfix/compile_graph_test.go
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package zenfix
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+func TestDaemonImportGraphOmitsXTools(t *testing.T) {
+ modRoot, err := filepath.Abs(filepath.Join("..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("go", "list", "-mod=vendor", "-deps", "./cmd/reticulum-go", "./pkg/zenfix")
+ cmd.Dir = modRoot
+ cmd.Env = append(os.Environ(), "GOPROXY=off", "GOFLAGS=-mod=vendor")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("go list: %v\n%s", err, out)
+ }
+ if bytes.Contains(out, []byte("golang.org/x/tools")) {
+ t.Fatal("cmd/reticulum-go or pkg/zenfix still depends on golang.org/x/tools")
+ }
+}

diff --git a/pkg/zenfix/files.go b/pkg/zenfix/files.go
new file mode 100644
index 00000000..e2b36701
--- /dev/null
+++ b/pkg/zenfix/files.go
@@ -0,0 +1,131 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package zenfix
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func listGoFiles(dir string, patterns []string, includeTests bool) ([]string, error) {
+ if dir == "" {
+ wd, err := os.Getwd()
+ if err != nil {
+ return nil, err
+ }
+ dir = wd
+ }
+ if len(patterns) == 0 {
+ patterns = []string{"./..."}
+ }
+ seen := make(map[string]struct{})
+ var out []string
+ for _, pat := range patterns {
+ files, err := listGoFilesPattern(dir, pat, includeTests)
+ if err != nil {
+ return nil, err
+ }
+ for _, f := range files {
+ if _, ok := seen[f]; ok {
+ continue
+ }
+ seen[f] = struct{}{}
+ out = append(out, f)
+ }
+ }
+ return out, nil
+}
+
+func listGoFilesPattern(dir, pattern string, includeTests bool) ([]string, error) {
+ pattern = strings.TrimSpace(pattern)
+ if pattern == "" {
+ pattern = "."
+ }
+ recursive := false
+ switch {
+ case pattern == "./..." || pattern == "...":
+ recursive = true
+ pattern = "."
+ case strings.HasSuffix(pattern, "/..."):
+ recursive = true
+ pattern = strings.TrimSuffix(pattern, "/...")
+ }
+ root := pattern
+ if !filepath.IsAbs(root) {
+ root = filepath.Join(dir, root)
+ }
+ info, err := os.Stat(root)
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ if isGoSource(root, includeTests) {
+ return []string{root}, nil
+ }
+ return nil, nil
+ }
+ if !recursive {
+ return listGoFilesInDir(root, includeTests)
+ }
+ var out []string
+ err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ name := d.Name()
+ if path != root && skipDirName(name) {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if isGoSource(path, includeTests) {
+ out = append(out, path)
+ }
+ return nil
+ })
+ return out, err
+}
+
+func listGoFilesInDir(dir string, includeTests bool) ([]string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ var out []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ path := filepath.Join(dir, e.Name())
+ if isGoSource(path, includeTests) {
+ out = append(out, path)
+ }
+ }
+ return out, nil
+}
+
+func skipDirName(name string) bool {
+ if name == "vendor" || name == "testdata" {
+ return true
+ }
+ if name == "" {
+ return true
+ }
+ c := name[0]
+ return c == '.' || c == '_'
+}
+
+func isGoSource(path string, includeTests bool) bool {
+ if !strings.HasSuffix(path, ".go") {
+ return false
+ }
+ base := filepath.Base(path)
+ if strings.HasSuffix(base, "_test.go") && !includeTests {
+ return false
+ }
+ return true
+}

diff --git a/pkg/zenfix/files_test.go b/pkg/zenfix/files_test.go
new file mode 100644
index 00000000..3361370a
--- /dev/null
+++ b/pkg/zenfix/files_test.go
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright (c) 2024-2026 Quad4.io
+
+package zenfix
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestListGoFilesSkipsTestdataAndVendor(t *testing.T) {
+ dir := t.TempDir()
+ write := func(rel, body string) {
+ t.Helper()
+ path := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("a.go", "package p\n")
+ write("a_test.go", "package p\n")
+ write("sub/b.go", "package sub\n")
+ write("testdata/hidden.go", "package hidden\n")
+ write("vendor/x/x.go", "package x\n")
+
+ all, err := listGoFiles(dir, []string{"./..."}, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(all) != 2 {
+ t.Fatalf("recursive files=%v want a.go and sub/b.go", all)
+ }
+
+ one, err := listGoFiles(dir, []string{"."}, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(one) != 1 || filepath.Base(one[0]) != "a.go" {
+ t.Fatalf("dir files=%v", one)
+ }
+
+ withTests, err := listGoFiles(dir, []string{"."}, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(withTests) != 2 {
+ t.Fatalf("with tests=%v", withTests)
+ }
+}

diff --git a/pkg/zenfix/options.go b/pkg/zenfix/options.go
index 20a9d4a3..c2232480 100644
--- a/pkg/zenfix/options.go
+++ b/pkg/zenfix/options.go
@@ -5,7 +5,8 @@ package zenfix
// Options configures zen analysis.
type Options struct {
- // Patterns are package patterns for golang.org/x/tools/go/packages (default ./...).
+ // Patterns are package or file globs relative to Dir (default ./...).
+ // ./... walks recursively. ./pkg/foo is that directory only.
Patterns []string
// Dir is the module root when Patterns are relative.
Dir string

diff --git a/reticulum-go.rsm b/reticulum-go.rsm
index cfff91e3..79e773a3 100644
Binary files a/reticulum-go.rsm and b/reticulum-go.rsm differ

Served by rngit 1.5.2 - Generated in 0.14s